Skip to content

fix(gc): a Symbol's description no longer lives in an untraced payload slot (#7246) - #7697

Merged
proggeramlug merged 5 commits into
mainfrom
fix/7246-symbol-description-offheap
Aug 9, 2026
Merged

fix(gc): a Symbol's description no longer lives in an untraced payload slot (#7246)#7697
proggeramlug merged 5 commits into
mainfrom
fix/7246-symbol-description-offheap

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes #7246.

The defect

SymbolHeader::description was a *mut StringHeader living inside a payload the
collector treats as opaque bytes. alloc_symbol gc_malloc's the header as
GC_TYPE_STRING, whose type info is pointer_free: true /
GcRewriteDescriptorKind::Leaf / GcLayoutSlotKind::None — correct for a
string, whose payload really is bytes, and wrong for a symbol, whose
payload's third word was a heap pointer. Symbols and strings share one GC type,
so no descriptor could tell them apart.

So a symbol that was itself perfectly rooted — shadow slot, side table,
everything — could have its description reaped or relocated out from under it.
SYMBOL_POINTERS did not close it either:
scan_symbol_pointer_metadata_roots_mut visits the set with
visit_metadata_usize_slot, which rewrites a recorded address without
marking
, and never looks at (*ptr).description at all.

The decision

The issue listed three candidates. This takes the third — intern off-heap — and
the reason it is cheap is the key.

FRESH_SYMBOL_DESCRIPTIONS is keyed on SymbolHeader::id, a monotonic u64
that an evacuation copies verbatim. Not on the symbol's address. Therefore:

Why not the other two:

  • GC_TYPE_SYMBOL is the principled fix, and it touches 190
    GC_TYPE_STRING sites across perry-runtime and perry-codegen, plus the GC
    type table's verification contract, is_symbol_pointer, heap_snapshot.rs
    and dead_owner.rs. Worth doing one day; not worth doing hurriedly.
  • Tracing the description from the side table needs the visit to be
    conditional on the symbol being live, which is a weak-table ordering problem —
    and SYMBOL_POINTERS can hold an entry for a symbol that is dead but not yet
    pruned, so the scanner would dereference freed memory to reach the
    description.

The retention cost the issue named as this option's price is paid down:
prune_dead_symbol_pointers prunes the descriptions on the same liveness
verdict that prunes the pointers.

Blast radius is small, and here is why

alloc_symbol has exactly two callers, both fresh symbols
(js_symbol_new_empty, js_symbol_new). Registered (Symbol.for) and
well-known symbols are Box::leak'd and already used the process-global
REGISTERED_SYMBOL_DESCRIPTIONS — untouched. Ids are globally monotonic, so the
thread-local and process-global maps never collide. The four readers
(js_symbol_key_for, js_symbol_description, js_symbol_to_string,
infer_symbol_function_name) now go through one symbol_description_text
helper instead of open-coding the registered_symbol_description(..).or_else(..)
chain — there were four of them, and a fifth that forgot the fallback is exactly
how a description goes silently missing.

Witness — the issue's own reproducer, A/B across the runtime rebuild

Same compiler, PERRY_RUNTIME_DIR pinned, PERRY_NO_AUTO_OPTIMIZE=1, no
compile-time GC env. Run arm as the issue specifies:

PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 \
PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1

before:  B 1    5/5 deterministic
after:   B 0   10/10          (node 26.5.1: B 0)

Movement confirmed live on the after-run (#7255): 87 copying minors, with
copied_objects=6008 and copied_objects=4743 on the two that mattered. A run
that moved nothing would prove nothing. The shipped default is also B 0.

Note the count moved from the B 2 the issue recorded to B 1 — same defect,
one probe now failing rather than two, which is what you would expect from the
allocation-shape churn since. It is still 5/5 deterministic, which is the tell
for this class: an unrooted cache goes bad at collection #0 and stays bad.

Unit tests — knob-free, and each one able to fail

crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs:

  1. StructuralSymbolHeader::description must stay null. This is the
    assertion a future change trips first, whether or not a behavioural test
    happens to catch it that day.
  2. Behavioural — the description survives a collection that reclaims the
    string it came from. The recycling loop afterwards (512 'Z'-filled strings)
    is load-bearing: without it a stale read can find the old bytes intact and
    the test passes for the wrong reason. It also asserts the payload pointer was
    null all along, so it cannot silently measure the old representation.
  3. The prune — descriptions of dead symbols go with their pointers.
    Otherwise interning off-heap trades a use-after-free for an unbounded leak,
    and the doc comment claiming otherwise would be the only evidence.

Sabotage: restoring (*ptr).description = description fails all three.

Not done here

test-files/test_gap_gc_symbol_local_rooting.ts still ships its
descriptionless-Symbol() carve-out and the header comment explaining it. The
issue says that can be dropped once this lands; it is a corpus-registered gap
file, so changing it wants a parity run rather than a code review, and it is
left as a follow-up.

Gates run locally

cargo fmt --all -- --check, cargo test -p perry-runtime --lib --no-fail-fast
(1938 passed / 0 failed), check_file_size.sh, check_test_registration.py,
global_sink_isolation.py — all green.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed symbol descriptions being lost or corrupted after garbage collection and memory reuse.
    • Symbol descriptions now remain available after their source strings are collected.
    • Improved handling of non-UTF-8 symbol description text.
    • Cleaned up descriptions for symbols that are no longer in use.
  • Documentation

    • Added changelog details and runtime coverage for symbol description behavior.
  • Chores

    • Updated the application version to 0.5.1400.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 295be1e4-8671-4ba4-a7e3-0de3ce7c890e

📥 Commits

Reviewing files that changed from the base of the PR and between 4ca7719 and 6220ac3.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/symbol.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/symbol.rs

📝 Walkthrough

Walkthrough

Fresh symbol descriptions are copied off the GC heap into ID-keyed storage. Readers use a shared lookup helper. GC pruning removes descriptions for dead symbols. Runtime-root tests validate pointer clearing, survival, and cleanup.

Changes

Symbol description lifetime fix

Layer / File(s) Summary
Off-heap storage and GC pruning
crates/perry-runtime/src/symbol.rs
Fresh symbol descriptions are copied into thread-local storage, symbol payload pointers are cleared, and dead-symbol pruning removes stale entries.
Centralized description readers
crates/perry-runtime/src/symbol/constructors.rs, crates/perry-runtime/src/symbol/properties.rs
Symbol APIs and function-name inference read descriptions through symbol_description_text.
GC validation and release metadata
crates/perry-runtime/src/gc/tests/runtime_roots.rs, crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs, changelog.d/7697-symbol-description-offheap.md, CLAUDE.md, Cargo.toml
Tests cover description lifetime and pruning. The changelog and version metadata are updated.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SymbolAllocation
  participant FreshDescriptionStorage
  participant GarbageCollector
  participant SymbolAPI
  SymbolAllocation->>FreshDescriptionStorage: copy description bytes by symbol ID
  SymbolAllocation->>GarbageCollector: allocate symbol with null description pointer
  GarbageCollector->>FreshDescriptionStorage: prune dead symbol IDs
  SymbolAPI->>FreshDescriptionStorage: retrieve description bytes
  FreshDescriptionStorage-->>SymbolAPI: return description text
Loading

Possibly related PRs

  • PerryTS/perry#7376: Both changes modify symbol description handling to address GC-related stale pointers.

Suggested labels: bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes unrelated version metadata edits in Cargo.toml and CLAUDE.md, which the repository template explicitly prohibits. Remove the Cargo.toml workspace version bump and CLAUDE.md version update; the maintainer handles these metadata changes at merge time.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix for the untraced Symbol description pointer.
Description check ✅ Passed The description covers the defect, implementation, linked issue, tests, and follow-up; it omits template headings and checklist but remains mostly complete.
Linked Issues check ✅ Passed The changes implement the off-heap interning fix and dead-symbol pruning required by [#7246], with shared readers and focused tests.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7246-symbol-description-offheap

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Re-verified after the WTF-8 refinement (a29187ae0), so the A/B in the description is not resting on the pre-refinement build.

The interned description became Arc<[u8]> rather than Arc<str>, because str_from_header UTF-8-validates and returns None on failure — a description built from a JS string with a lone surrogate is WTF-8, and interning through String would have turned it into undefined. That is a behaviour change I nearly smuggled in on a GC fix; it is closed, and the residual (the rebuilt StringHeader does not carry STRING_FLAG_HAS_LONE_SURROGATES, which is not recoverable from the payload) is stated in the code rather than hidden.

Rebuilt -p perry -p perry-runtime-static -p perry-stdlib-static, .a mtime confirmed moved, PERRY_RUNTIME_DIR pinned, PERRY_NO_AUTO_OPTIMIZE=1:

PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 \
PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1

B 0   10/10        (node 26.5.1: B 0)

cargo test -p perry-runtime --lib --no-fail-fast — 1938 passed, 0 failed, cargo exit 0, 0 compile errors, Running unittests observed.

Ralph Küpper added 4 commits August 9, 2026 15:26
…d slot (#7246)

`SymbolHeader::description` was a `*mut StringHeader` inside a payload the
collector treats as opaque bytes. `alloc_symbol` gc_malloc's the header as
`GC_TYPE_STRING`, whose type info is `pointer_free: true` /
`GcRewriteDescriptorKind::Leaf` / `GcLayoutSlotKind::None` — correct for a
string, whose payload IS bytes, and wrong for a symbol, whose payload's third
word was a heap pointer. Symbols and strings share one GC type, so no descriptor
could distinguish them. A symbol that was itself perfectly rooted could have its
description reaped or relocated out from under it, and `String(sym)` /
`sym.description` then read recycled memory. `SYMBOL_POINTERS` did not close it:
`scan_symbol_pointer_metadata_roots_mut` uses `visit_metadata_usize_slot`, which
rewrites a recorded address WITHOUT marking, and never looks at
`(*ptr).description` at all.

The pointer is REMOVED rather than traced. `alloc_symbol` copies the description
text off the GC heap before it allocates and leaves the field null;
`FRESH_SYMBOL_DESCRIPTIONS` holds it, keyed on `SymbolHeader::id`.

Why that beat the other two candidates the issue listed:

* the key is the ID, which an evacuation copies verbatim — so the table needs no
  rekey pass, no root scanner and no budgeted step twin (where #7239 found the
  one real drift). It holds no GC pointer at all.
* the text is copied BEFORE the allocation, so there is no window in which a
  description pointer is live-but-untraced. #7341's `RuntimeHandleScope` +
  `across_mut` in `alloc_symbol` is gone with it: it made the STORED pointer
  correct across `gc_malloc`, and there is no longer a stored pointer.
* a `GC_TYPE_SYMBOL` would have been the principled fix but touches 190
  `GC_TYPE_STRING` sites across runtime and codegen, plus the type table's
  verification contract.
* the descriptions are pruned in `prune_dead_symbol_pointers` on the same
  liveness verdict that prunes `SYMBOL_POINTERS`, which pays down the retention
  cost the issue named as this option's price.

`alloc_symbol` has exactly two callers, both fresh (`Symbol()` / `Symbol(desc)`);
registered and well-known symbols are `Box::leak`'d and keep using the
process-global `REGISTERED_SYMBOL_DESCRIPTIONS`. Ids are globally monotonic, so
the thread-local and process-global maps never collide. The four readers now go
through one `symbol_description_text` helper instead of open-coding the
`registered_symbol_description(..).or_else(..)` chain.

Witness, the issue's own reproducer, same compiler, A/B across the runtime
rebuild (`PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0
PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_FORCE_EVACUATE=1`):

    before:  B 1   5/5 deterministic
    after:   B 0  10/10        (node 26.5.1: B 0)

Movement confirmed live on the after-run: 87 copying minors, `copied_objects`
6008 / 4743 on the two that mattered.

Plus three knob-free unit tests in `gc/tests/runtime_roots/symbol_description.rs`
— structural (the payload pointer stays null), behavioural (the description
survives reclamation of the string it came from, with the from-space bytes
recycled into 'Z'-filled strings first so a stale read cannot pass by luck), and
the prune. Sabotage-verified: restoring `(*ptr).description = description` fails
all three.
…7246)

`str_from_header` UTF-8-validates and returns `None` on failure, and a
description built from a JS string carrying a lone surrogate is WTF-8, not
UTF-8. Interning through `String` would therefore have turned a lone-surrogate
`sym.description` from a string into `undefined` — a behaviour change smuggled
in on a GC fix, in an area CLAUDE.md already lists as a known gap.

The interned description is now `Arc<[u8]>` and round-trips through
`js_string_from_bytes` unchanged. `js_symbol_to_string` still renders lossily,
which is what `str_from_header(..).unwrap_or_default()` did before: a WTF-8
description was never formattable into a Rust `String` losslessly.

Residual, stated in the code rather than hidden: the rebuilt `StringHeader` does
not carry `STRING_FLAG_HAS_LONE_SURROGATES`, because the original flag is not
recoverable from the payload. Pre-existing WTF-8 gap, and strictly better than
dropping the description.
@proggeramlug
proggeramlug force-pushed the fix/7246-symbol-description-offheap branch from a29187a to 4ca7719 Compare August 9, 2026 13:29

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs (1)

118-162: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for a lone-surrogate description.

The PR chose Arc<[u8]> over Arc<str> specifically so a WTF-8 description is not turned into undefined by UTF-8 validation. No test locks that behaviour in. A future change back to Arc<str> or to str_from_header would pass all three tests here.

Add a fourth test that allocates a symbol whose description contains a lone surrogate, then asserts js_symbol_description returns a string rather than undefined and that the bytes round-trip unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs` around
lines 118 - 162, In the runtime roots symbol-description tests, add a dedicated
test alongside dead_symbols_descriptions_are_pruned_with_their_pointers that
allocates a symbol from a lone-surrogate WTF-8 description, calls
js_symbol_description, and asserts the result is a string rather than undefined
with bytes identical to the original description. Reuse the existing GC guards,
symbol allocation, string-byte inspection, and cleanup helpers so the test
verifies unchanged round-tripping.
crates/perry-runtime/src/symbol.rs (1)

453-467: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Keep the description id with each registered symbol pointer.

prune_dead_symbol_pointers keeps entries for foreign process-global symbol pointers because gc::dead_owner skips unattributable owners, then dereferences them here. Store the id in the scan/prune state or at registration time so this pass never reads (*ptr).id for retained pointers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/symbol.rs` around lines 453 - 467, Update
prune_dead_symbol_pointers and the SYMBOL_POINTERS registration state to retain
each symbol pointer together with its description id, captured when the symbol
is registered or scanned. Use the stored id when populating live_ids after
retain, eliminating the unsafe dereference of retained pointers while preserving
dead-pointer pruning.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs`:
- Around line 118-162: In the runtime roots symbol-description tests, add a
dedicated test alongside
dead_symbols_descriptions_are_pruned_with_their_pointers that allocates a symbol
from a lone-surrogate WTF-8 description, calls js_symbol_description, and
asserts the result is a string rather than undefined with bytes identical to the
original description. Reuse the existing GC guards, symbol allocation,
string-byte inspection, and cleanup helpers so the test verifies unchanged
round-tripping.

In `@crates/perry-runtime/src/symbol.rs`:
- Around line 453-467: Update prune_dead_symbol_pointers and the SYMBOL_POINTERS
registration state to retain each symbol pointer together with its description
id, captured when the symbol is registered or scanned. Use the stored id when
populating live_ids after retain, eliminating the unsafe dereference of retained
pointers while preserving dead-pointer pruning.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 962a424b-c784-48a4-bcad-9b6b9ae7b1c4

📥 Commits

Reviewing files that changed from the base of the PR and between b0e4a28 and 4ca7719.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7697-symbol-description-offheap.md
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/symbol_description.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/symbol/constructors.rs
  • crates/perry-runtime/src/symbol/properties.rs

addr_class_inventory ratcheted symbol.rs at 3 handle-floor sites; the two new
dereference guards added a 4th and 5th. A bare < 0x1000 floor does not reject
the fetch/zlib/proxy handle bands, which segfault on Linux.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1400, with one fix applied

The defect is the sharpest kind: a type that is right for one thing and wrong for another sharing it. alloc_symbol gc_mallocs its header as GC_TYPE_STRING, whose type info is pointer_free: true / Leaf / SlotKind::None — correct for a string, whose payload really is bytes, and wrong for a symbol, whose third payload word was a heap pointer. Symbols and strings share one GC type, so no descriptor could tell them apart. A perfectly rooted symbol could still have its description reaped.

And SYMBOL_POINTERS did not close it, for a reason worth keeping: visit_metadata_usize_slot rewrites a recorded address without marking, and never touches (*ptr).description at all. A scanner that rewrites but does not mark looks like coverage in every audit that counts registrations.

Removing the pointer rather than tracing it is the right of the three candidates, and the key is why it is cheap: FRESH_SYMBOL_DESCRIPTIONS is keyed on SymbolHeader::id, a monotonic u64 an evacuation copies verbatim — not on the address. So there is no rekey, no scanner, and no budgeted-step twin.

Verified independently, 200 symbols across 200k allocations of churn, compiled with PERRY_GC_MOVING_LOOP_POLLS=1:

output
node 26.5.1 B 0 desc-7 Symbol(desc-3) lone 3
this PR byte-identical
+ PERRY_GC_ZEAL=1 byte-identical

lone 3 is the case that matters most: storing as Arc<[u8]> rather than str keeps a lone-surrogate description intact. You caught yourself about to turn that into undefined; str_from_header would have validated it away, and no test in the suite would have noticed.

One fix I applied

addr_class_inventory went red: the two new dereference guards used a bare (ptr as usize) < 0x1000 floor, taking symbol.rs from its ratcheted 3 handle-floor sites to 5. That floor does not reject the fetch/zlib/proxy handle bands, and dereferencing one segfaults on Linux while macOS silently hides it (#1843/#4004/#6271). Converted both to addr_class::is_above_handle_band, which is the predicate that does. Re-verified: output unchanged, node-identical, gates 24/24.

Worth noting the gate caught this before merge because I now run all 24 through a script that exits non-zero and gate the merge on it — I merged twice today with a red gate by printing the failure and merging anyway.

Gates: 24/24, perry-runtime --lib all green, symbol suite 30/30.

@proggeramlug
proggeramlug merged commit c481762 into main Aug 9, 2026
11 of 13 checks passed
@proggeramlug
proggeramlug deleted the fix/7246-symbol-description-offheap branch August 9, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC: a Symbol's description pointer is never traced — GC_TYPE_STRING is a pointer-free Leaf

1 participant